// Copyright (c) Appeon. All rights reserved.
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. 
// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
using SnapObjects.Commons;
using System.Threading;

namespace SnapObjects.Data
{
    /// <summary>
    ///     Provides a series of methods for manipulating data after loaded.
    /// </summary>
    /// <typeparam name="TModel">The type of a model class.</typeparam>
    internal class ModelLoadable<TModel>
        : ILoadable<TModel>
    {
        private readonly DataContext _context;
        private readonly ModelSelectBuilder _modelSelectBuilder;

        private bool _isListResult;
        private IList<TModel> _models;

        public ModelLoadable(
            DataContext context,
            ModelSelectBuilder modelSelectBuilder)
        {
            _context = context;
            _modelSelectBuilder = modelSelectBuilder;
        }

        public ModelLoadable(
            DataContext context,
            ModelSelectBuilder modelSelectBuilder,
            TModel model,
            IReadOnlyList<IAdoDbParameter> parameters)
        {
            _context = context;
            _modelSelectBuilder = modelSelectBuilder;
            this.MasterModel = model;
            this.BoundParameters = parameters;
        }

        public ModelLoadable(
           DataContext context,
           ModelSelectBuilder modelSelectBuilder,
           IList<TModel> models,
           IReadOnlyList<IAdoDbParameter> parameters)
        {
            _context = context;
            _isListResult = true;
            _models = models;
            _modelSelectBuilder = modelSelectBuilder;

            this.BoundParameters = parameters;
        }

        public IReadOnlyList<IAdoDbParameter> BoundParameters { get; private set; }

        public TModel MasterModel { get; private set; }

        /// <summary>
        ///     Returns the first data record in the result set. If there is no data in the result set, 
        ///     returns the default value.
        /// </summary>
        /// <returns>
        ///     Returns a TModel object which represents the first row of the result set loaded, 
        ///     or returns null if no rows loaded.
        /// </returns>
        public TModel FirstOrDefault()
        {
            if (_isListResult)
            {
                return _models != null && _models.Count > 0 ?
                            _models[0] : default;
            }
            else
            {
                return this.MasterModel;
            }
        }

        /// <summary>
        ///     Creates a <see cref="List{TModel}"/> object from the result set.
        /// </summary>
        /// <returns>Returns a <see cref="List{TModel}"/> object that includes all of the data of the result set.</returns>
        public List<TModel> ToList()
        {
            if (_isListResult)
            {
                return _models == null ?
                        new List<TModel>() : (List<TModel>)_models;
            }
            else
            {
                var models = new List<TModel>();

                if (this.MasterModel != null)
                {
                    models.Add(this.MasterModel);
                }

                return models;
            }
        }

        /// <summary>
        ///     Converts the result set to an array.
        /// </summary>
        /// <returns>An array that contains all of the data of the result set.</returns>
        public TModel[] ToArray()
        {
            return this.ToList().ToArray();
        }

        /// <summary>
        ///     Creates a <see cref="Dictionary{Tkey, TModel}"/> object from the result set according to the specified key selector function.
        /// </summary>
        /// <typeparam name="TKey"></typeparam>
        /// <param name="keySelector">A function used to specify the key of the dictionary.</param>
        /// <returns>Returns a <see cref="Dictionary{Tkey, TModel}"/> object that includes all of the data of the result set.</returns>
        public Dictionary<TKey, TModel> ToDictionary<TKey>(Func<TModel, TKey> keySelector)
        {
            return this.ToList().ToDictionary(keySelector);
        }

        public ILoadable<TModel> Load(params object[] parameters)
        {
            var queryBuilder = _modelSelectBuilder.GetModelQueryBuilder(typeof(TModel));

            this.LoadInternal(queryBuilder, parameters);

            return this;
        }

        public Task<ILoadable<TModel>> LoadAsync(object[] parameters, CancellationToken cancellationToken)
        {
            var queryBuilder = _modelSelectBuilder.GetModelQueryBuilder(typeof(TModel));

            return this.LoadInternalAsync(queryBuilder, parameters, cancellationToken);
        }

        public ILoadable<TModel> LoadByPage(
            int currentIndex,
            int pageSize,
            params object[] parameters)
        {
            var queryBuilder = _modelSelectBuilder.GetModelQueryBuilder(typeof(TModel));

            var definedParameters = queryBuilder.GetSqlParameters();
            var adoDbParameters = ModelParameterHelper.BindParameters(
                                    _context,
                                    definedParameters,
                                    parameters);

            _models = new ModelSqlExecutor(_context).SelectPage<TModel>(
                        queryBuilder.SqlQueryBuilder.ToSqlString(_context),
                        currentIndex,
                        pageSize,
                        adoDbParameters);

            this.BoundParameters = (IReadOnlyList<IAdoDbParameter>)adoDbParameters;
            _isListResult = true;

            return this;
        }

        public async Task<ILoadable<TModel>> LoadByPageAsync(
            int currentIndex,
            int pageSize,
            object[] parameters,
            CancellationToken cancellationToken)
        {
            var queryBuilder = _modelSelectBuilder.GetModelQueryBuilder(typeof(TModel));

            var definedParameters = queryBuilder.GetSqlParameters();
            
            var adoDbParameters = ModelParameterHelper.BindParameters(
                                    _context,
                                    definedParameters,
                                    parameters);

            var executor = new ModelSqlExecutor(_context);

            _models = await executor.SelectPageAsync<TModel>(
                         queryBuilder.SqlQueryBuilder.ToSqlString(_context),
                        currentIndex,
                        pageSize,
                        adoDbParameters,
                        cancellationToken);

            this.BoundParameters = (IReadOnlyList<IAdoDbParameter>)adoDbParameters;

            _isListResult = true;

            return this;
        }

        public ILoadable<TModel> LoadAll()
        {
            var sqlText = _modelSelectBuilder.BuildSelectIgnoreWhere(typeof(TModel));

            _isListResult = true;
            _models = new ModelSqlExecutor(_context)
                        .Select<TModel>(sqlText, new List<IAdoDbParameter>());

            //Reset
            this.BoundParameters = new List<IAdoDbParameter>();

            return this;
        }

        public async Task<ILoadable<TModel>> LoadAllAsync(CancellationToken cancellationToken)
        {
            var sqlText = _modelSelectBuilder.BuildSelectIgnoreWhere(typeof(TModel));

            _isListResult = true;

            var executor = new ModelSqlExecutor(_context);

            _models = await executor.SelectAsync<TModel>(sqlText, new List<IAdoDbParameter>(), cancellationToken);

            //Reset
            this.BoundParameters = new List<IAdoDbParameter>();

            return this;
        }

        public ILoadable<TModel> LoadAllByPage(
            int currentIndex,
            int pageSize)
        {
            var sqlText = _modelSelectBuilder.BuildSelectIgnoreWhere(typeof(TModel));

            _isListResult = true;
            _models = new ModelSqlExecutor(_context)
                        .SelectPage<TModel>(sqlText, currentIndex, pageSize, new List<IAdoDbParameter>());

            //Reset
            this.BoundParameters = new List<IAdoDbParameter>();

            return this;
        }

        public async Task<ILoadable<TModel>> LoadAllByPageAsync(
            int currentIndex, int pageSize, CancellationToken cancellationToken)
        {
            var sqlText = _modelSelectBuilder.BuildSelectIgnoreWhere(typeof(TModel));

            _isListResult = true;

            var executor = new ModelSqlExecutor(_context);

            _models = await executor.SelectPageAsync<TModel>(
                sqlText, currentIndex, pageSize, new List<IAdoDbParameter>(), cancellationToken);

            //Reset
            this.BoundParameters = new List<IAdoDbParameter>();

            return this;
        }

        private ILoadable<TModel> LoadInternal(
            ModelQueryBuilder queryBuilder,
            params object[] parameters)
        {
            var definedParameters = queryBuilder.GetSqlParameters();
            var adoDbParameters = ModelParameterHelper.BindParameters(
                                    _context,
                                    definedParameters,
                                    parameters);

            _models = new ModelSqlExecutor(_context).Select<TModel>(
                        queryBuilder.SqlQueryBuilder.ToSqlString(_context),
                        adoDbParameters);

            this.BoundParameters = (IReadOnlyList<IAdoDbParameter>)adoDbParameters;
            _isListResult = true;

            return this;
        }

        private async Task<ILoadable<TModel>> LoadInternalAsync(
           ModelQueryBuilder queryBuilder,
           object[] parameters,
           CancellationToken cancellationToken)
        {
            var definedParameters = queryBuilder.GetSqlParameters();

            var adoDbParameters = ModelParameterHelper.BindParameters(
                                    _context,
                                    definedParameters,
                                    parameters);

            var executor = new ModelSqlExecutor(_context);

            _models = await executor.SelectAsync<TModel>(
                        queryBuilder.SqlQueryBuilder.ToSqlString(_context),
                        adoDbParameters,
                        cancellationToken);

            this.BoundParameters = (IReadOnlyList<IAdoDbParameter>)adoDbParameters;

            _isListResult = true;

            return this;
        }

        public ILoadable<TModel> LoadByKey(params object[] parameters)
        {
            var sqlText = _modelSelectBuilder.BuildSelectByKey(
                typeof(TModel),
                out var sqlParameters);

            //LoadByKey using Key properties as parameters
            var adoDbParameters = ModelParameterHelper.BindParameters(
                                    _context,
                                    sqlParameters,
                                    parameters);

            this.MasterModel = new ModelSqlExecutor(_context).SelectOneByKey<TModel>(
                            sqlText,
                            adoDbParameters);

            //Reset
            this.BoundParameters = new List<IAdoDbParameter>();
            _isListResult = false;

            return this;
        }

        public async Task<ILoadable<TModel>> LoadByKeyAsync(object[] parameters, CancellationToken cancellationToken)
        {
            var sqlText = _modelSelectBuilder.BuildSelectByKey(
                typeof(TModel),
                out var sqlParameters);

            //LoadByKey using Key properties as parameters
            var adoDbParameters = ModelParameterHelper.BindParameters(
                                    _context,
                                    sqlParameters,
                                    parameters);

            var executor = new ModelSqlExecutor(_context);

            this.MasterModel = await executor.SelectOneByKeyAsync<TModel>(
                sqlText, adoDbParameters, cancellationToken);

            //Reset
            this.BoundParameters = new List<IAdoDbParameter>();
            _isListResult = false;

            return this;
        }

        /// <summary>
        ///     Loads data for the embedded properties in TModel for all rows. If the ModelEmbedded attribute is applied 
        ///     in the property, the property will use the SQL query(defined in the detail model class specified by the 
        ///     ModelType property of the ModelEmbedded attribute) to retrieve data from database.The cascade parameter 
        ///     can be used to decide whether to retrieve data for the embedded properties in the detail model or the 
        ///     granddetail model.
        /// </summary>
        /// <param name="cascade">
        ///     Only when the ModelEmbedded attribute is applied in the property, the cascade parameter can be used to 
        ///     decide whether to retrieve data for the embedded properties in the detail model or the granddetail model. 
        ///     See master-detail-granddetail for more info. The default value is false.
        /// </param>
        /// <returns>
        ///     Returns the current <see cref="ILoadable{TModel}"/> object whose methods can be used to further obtain the data result 
        ///     set or perform data operations such as embedded queries.
        /// </returns>
        public ILoadable<TModel> IncludeAll(bool cascade = false)
        {
            var modelMeta = ModelMetaManager.GetModelMeta(typeof(TModel));

            foreach (var property in modelMeta.EmbeddedProperties)
            {
                this.Include(property.Name, cascade);
            }

            return this;
        }

        public async Task<ILoadable<TModel>> IncludeAllAsync(bool cascade = false, CancellationToken cancellationToken = default)
        {
            var modelMeta = ModelMetaManager.GetModelMeta(typeof(TModel));

            foreach (var property in modelMeta.EmbeddedProperties)
            {
                await this.IncludeAsync(property.Name, cascade, cancellationToken);
            }

            return this;
        }

        /// <summary>
        ///     Loads data for the embedded properties (where the ModelEmbedded attribute or the SqlEmbedded attribute is applied) 
        ///     in TModel for the specified row. If the ModelEmbedded attribute is applied in the property, the property will use 
        ///     the SQL query(defined in the detail model class specified by the ModelType property of the ModelEmbedded attribute) 
        ///     to retrieve data from database.The cascade parameter can be used to decide whether to retrieve data for the embedded 
        ///     properties in the detail model or the granddetail mode.
        /// </summary>
        /// <param name="index">The zero-based index for the row in the result set.</param>
        /// <param name="cascade">
        ///     Only when the ModelEmbedded attribute is applied in the property, the cascade parameter can be used to decide 
        ///     whether to retrieve data for embedded properties in the detail model or granddetail model. See master-detail-granddetail 
        ///     for more info. The default value is false.
        /// </param>
        /// <returns>
        ///     Returns the current <see cref="ILoadable{TModel}"/> object whose methods can be used to further obtain the data result set or 
        ///     perform data operations such as embedded queries.
        /// </returns>
        public ILoadable<TModel> IncludeAll(int index, bool cascade = false)
        {
            var modelMeta = ModelMetaManager.GetModelMeta(typeof(TModel));

            foreach (var property in modelMeta.EmbeddedProperties)
            {
                this.Include(index, property.Name, cascade);
            }

            return this;
        }

        public async Task<ILoadable<TModel>> IncludeAllAsync(
            int index, bool cascade = false, CancellationToken cancellationToken = default)
        {
            var modelMeta = ModelMetaManager.GetModelMeta(typeof(TModel));

            foreach (var property in modelMeta.EmbeddedProperties)
            {
                await this.IncludeAsync(index, property.Name, cascade, cancellationToken);
            }

            return this;
        }

        /// <summary>
        ///     Loads data for the specified embedded property in TModel for all rows.If the ModelEmbedded 
        ///     attribute is applied in the property, the property will use the SQL query(defined in the 
        ///     detail model class specified by the ModelType property of the ModelEmbedded attribute) to 
        ///     retrieve data from database.The cascade parameter can be used to decide whether to retrieve 
        ///     data for the embedded properties in the detail model or the granddetail model.
        /// </summary>
        /// <param name="propertyExpr">
        ///     An embedded property of TModel class.
        ///     This property is specified by an expression.
        /// </param>
        /// <param name="cascade">
        /// Only when the ModelEmbedded attribute is applied in the property, the cascade parameter can be 
        /// used to decide whether to retrieve data for the embedded properties in the detail model or the 
        /// granddetail model. See master-detail-granddetail for more info.The default value is false.
        /// </param>
        /// <returns>
        ///     Returns the current <see cref="ILoadable{TModel}"/> object whose methods can be used to further obtain 
        ///     the data result set or perform data operations such as embedded queries.
        /// </returns>
        public ILoadable<TModel> Include(
            Expression<Func<TModel, object>> propertyExpr,
            bool cascade = false)
        {
            Check.NotNull(propertyExpr, nameof(propertyExpr));

            var propertyInfo = ModelHelper.ExtractProperty(propertyExpr);

            return this.Include(propertyInfo.Name, cascade);
        }

        public Task<ILoadable<TModel>> IncludeAsync(
            Expression<Func<TModel, object>> propertyExpr,
            bool cascade = false,
            CancellationToken cancellationToken = default)
        {
            Check.NotNull(propertyExpr, nameof(propertyExpr));

            var propertyInfo = ModelHelper.ExtractProperty(propertyExpr);

            return this.IncludeAsync(propertyInfo.Name, cascade, cancellationToken);
        }

        /// <summary>
        ///     Loads data for the specified embedded property in TModel for the specified row. If the ModelEmbedded 
        ///     attribute is applied in the property, the property will use the SQL query(defined in the detail model 
        ///     class specified by the ModelType property of the ModelEmbedded attribute) to retrieve data from database.
        ///     The cascade parameter can be used to decide whether to retrieve data for the embedded properties in the 
        ///     detail model or the granddetail model.
        /// </summary>
        /// <param name="index">The zero-based index for the row in the result set.</param>
        /// <param name="propertyExpr">
        ///     An embedded property of TModel class.
        ///     This property is specified by an expression.
        /// </param>
        /// <param name="cascade">
        ///     Only when the ModelEmbedded attribute is applied in the property, the cascade parameter can be used 
        ///     to decide whether to retrieve data for the embedded properties in the detail model or granddetail model. 
        ///     See master-detail-granddetail for more info.The default value is false.
        /// </param>
        /// <returns>
        ///     Returns the current <see cref="ILoadable{TModel}"/> object whose methods can be used to further obtain the data result 
        ///     set or perform data operations such as embedded queries.
        /// </returns>
        public ILoadable<TModel> Include(
            int index,
            Expression<Func<TModel, object>> propertyExpr,
            bool cascade = false)
        {
            Check.NotNull(propertyExpr, nameof(propertyExpr));

            var propertyInfo = ModelHelper.ExtractProperty(propertyExpr);

            return this.Include(index, propertyInfo.Name, cascade);
        }

        public Task<ILoadable<TModel>> IncludeAsync(
           int index,
           Expression<Func<TModel, object>> propertyExpr,
           bool cascade = false,
           CancellationToken cancellationToken = default)
        {
            Check.NotNull(propertyExpr, nameof(propertyExpr));

            var propertyInfo = ModelHelper.ExtractProperty(propertyExpr);

            return this.IncludeAsync(index, propertyInfo.Name, cascade, cancellationToken);
        }

        private ILoadable<TModel> Include(
            string propertyName,
            bool cascade)
        {
            var modelMeta = ModelMetaManager.GetModelMeta(typeof(TModel));

            if (_isListResult)
            {
                if (_models != null &&
                    modelMeta.IsEmbedded(propertyName))
                {
                    for (var i = 0; i < _models.Count; i++)
                    {
                        //LoadEmbedded(_models[i], propertyName, parameters);
                        this.Include(_models[i], propertyName, cascade, this.BoundParameters);
                    }
                }
            }
            else
            {
                if (this.MasterModel != null &&
                    modelMeta.IsEmbedded(propertyName))
                {
                    //LoadEmbedded(_model, propertyName, parameters);
                    this.Include(this.MasterModel, propertyName, cascade, this.BoundParameters);
                }
            }

            return this;
        }

        private async Task<ILoadable<TModel>> IncludeAsync(
            string propertyName, bool cascade, CancellationToken cancellationToken)
        {
            var modelMeta = ModelMetaManager.GetModelMeta(typeof(TModel));

            if (_isListResult)
            {
                if (_models != null &&
                    modelMeta.IsEmbedded(propertyName))
                {
                    for (var i = 0; i < _models.Count; i++)
                    {
                        await this.IncludeAsync(_models[i], propertyName, cascade, this.BoundParameters, cancellationToken);
                    }
                }
            }
            else
            {
                if (this.MasterModel != null &&
                    modelMeta.IsEmbedded(propertyName))
                {
                    await this.IncludeAsync(this.MasterModel, propertyName, cascade, this.BoundParameters, cancellationToken);
                }
            }

            return this;
        }

        private ILoadable<TModel> Include(
            int index,
            string propertyName,
            bool cascade)
        {
            var modelMeta = ModelMetaManager.GetModelMeta(typeof(TModel));

            if (_isListResult)
            {
                if (_models != null &&
                    modelMeta.IsEmbedded(propertyName))
                {
                    if (index >= 0 || index < _models.Count)
                    {
                        this.Include(_models[index], propertyName, cascade, this.BoundParameters);
                    }
                    else
                    {
                        throw new IndexOutOfRangeException();
                    }
                }
            }
            else
            {
                throw new InvalidOperationException("The resultset is not a collection, using Include(propertyExpr, cascade) instead.");
            }

            return this;
        }

        private async Task<ILoadable<TModel>> IncludeAsync(
            int index,
            string propertyName,
            bool cascade,
            CancellationToken cancellationToken)
        {
            var modelMeta = ModelMetaManager.GetModelMeta(typeof(TModel));

            if (_isListResult)
            {
                if (_models != null &&
                    modelMeta.IsEmbedded(propertyName))
                {
                    if (index >= 0 || index < _models.Count)
                    {
                        await this.IncludeAsync(_models[index], propertyName, cascade, this.BoundParameters, cancellationToken);
                    }
                    else
                    {
                        throw new IndexOutOfRangeException();
                    }
                }
            }
            else
            {
                throw new InvalidOperationException("The resultset is not a collection, using Include(propertyExpr, cascade) instead.");
            }

            return this;
        }

        private void Include(
            object parentModel,
            string property,
            bool cascade,
            IReadOnlyList<IAdoDbParameter> parentParameters)
        {
            if (parentModel == null)
            {
                return;
            }

            var embeddedLoader = new EmbeddedLoader(_context, _modelSelectBuilder);
            var result = embeddedLoader.LoadEmbedded(parentModel, property, parentParameters);

            if (result == null || !embeddedLoader.IsResultModel || !cascade)
            {
                return;
            }

            PocoModelMeta itemMeta = null;

            if (embeddedLoader.IsResultCollection)
            {
                var collection = (IEnumerable)result;

                foreach (var item in collection)
                {
                    itemMeta ??= ModelMetaManager.GetModelMeta(item.GetType());

                    foreach (var embedded in itemMeta.EmbeddedProperties)
                    {
                        if (embedded.EmbeddedType.IsModel())
                        {
                            this.Include(item, embedded.Name, cascade, embeddedLoader.BoundParameters);
                        }
                    }
                }
            }
            else
            {
                var embeddedModel = result;
                itemMeta = ModelMetaManager.GetModelMeta(embeddedModel.GetType());

                foreach (var embedded in itemMeta.EmbeddedProperties)
                {
                    if (embedded.EmbeddedType.IsModel())
                    {
                        this.Include(embeddedModel, embedded.Name, cascade, embeddedLoader.BoundParameters);
                    }
                }
            }
        }

        private async Task IncludeAsync(
            object parentModel,
            string property,
            bool cascade,
            IReadOnlyList<IAdoDbParameter> parentParameters,
            CancellationToken cancellationToken)
        {
            if (parentModel == null)
            {
                return;
            }

            var embeddedLoader = new EmbeddedLoader(_context, _modelSelectBuilder);

            var result = await embeddedLoader
                .LoadEmbeddedAsync(parentModel, property, parentParameters, cancellationToken);

            if (result == null || !embeddedLoader.IsResultModel || !cascade)
            {
                return;
            }

            PocoModelMeta itemMeta = null;

            if (embeddedLoader.IsResultCollection)
            {
                var collection = (IEnumerable)result;

                foreach (var item in collection)
                {
                    itemMeta ??= ModelMetaManager.GetModelMeta(item.GetType());

                    foreach (var embedded in itemMeta.EmbeddedProperties)
                    {
                        if (embedded.EmbeddedType.IsModel())
                        {
                            await this.IncludeAsync(item, embedded.Name, cascade, embeddedLoader.BoundParameters, cancellationToken);
                        }
                    }
                }
            }
            else
            {
                var embeddedModel = result;
                itemMeta = ModelMetaManager.GetModelMeta(embeddedModel.GetType());

                foreach (var embedded in itemMeta.EmbeddedProperties)
                {
                    if (embedded.EmbeddedType.IsModel())
                    {
                        await this.IncludeAsync(
                            embeddedModel, embedded.Name, cascade, embeddedLoader.BoundParameters, cancellationToken);
                    }
                }
            }
        }
    }
}
